fix(socket-mode): tear down leaked sockets on disconnect (#2709) - #2710
Conversation
First TDD step for issue #2709. Adds and refines red tests reproducing the three defects behind the WebSocket socket leak introduced by the ws -> undici migration: - disconnect() has no close-handshake timeout, so an unresponsive peer leaves the connection hung and 'close' never fires - cleanup() never destroys the underlying socket - the 'close' reconnect path has no active-connection guard and no timer dedup, so stale/duplicate 'close' events can spawn extra connections All four tests fail for their intended reasons; source fixes follow in a later pass. Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
Porting from `ws` to undici's WebSocket in 3.0.0 dropped every force-close mechanism, leaving ESTABLISHED TCP sockets to accumulate toward Slack's per-app connection cap. Three independent defects, each with a preceding failing test: - disconnect() sent a close frame with no timeout, so a dead peer left the socket in CLOSING forever. Arm a 30s close-handshake timeout that forces cleanup. - cleanup() never destroyed the underlying TCP socket. When no user dispatcher is supplied, capture the raw socket via a custom Agent connector and destroy it (plus the Agent) on cleanup. A user-supplied dispatcher owns its socket and relies on the close-handshake timeout; this limitation is documented on the dispatcher option. - the 'close' handler reconnected on every close. Guard against stale closes while still active and dedupe overlapping closes so at most one reconnect is scheduled. Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
🦋 Changeset detectedLatest commit: 360516d The changes in this PR will be included in the next version bump. Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #2710 +/- ##
==========================================
- Coverage 89.21% 89.12% -0.09%
==========================================
Files 65 65
Lines 10393 10441 +48
Branches 473 482 +9
==========================================
+ Hits 9272 9306 +34
- Misses 1089 1103 +14
Partials 32 32
Flags with carried forward coverage won't be shown. Click here to find out more. 🚀 New features to boost your workflow:
|
…nt Agent tracking Internal refactor of SlackWebSocket with no public API or behavior change. - Lift the inline undici `Agent` construction in `connect()` into a private `buildDefaultDispatcher()` helper, reducing `connect()` to a two-line dispatcher selection. - Remove the `ownAgent` field and its `cleanup()` teardown. undici already evicts and closes its pooled dispatcher when the client disconnects at WebSocket upgrade, so `Agent.destroy()` was a no-op on established connections; the real teardown remains `defaultSocket.destroy()`. - Rename `capturedSocket` -> `defaultSocket` (the socket is only captured on the default path) and widen its type to `Socket | TLSSocket | null` to match undici's connector callback, so the helper stores and forwards the socket without casts. Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
Reframe the `dispatcher` option comment around overriding the default dispatcher, dropping the omitted/supplied split for a shorter, clearer note on the one practical consequence (force-close vs. timeout fallback). Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
Replace the internal-refactor changeset with one describing the user-facing fix: leaked TCP sockets on disconnect (#2709). Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
Rename the undici connector callback param from `cb` to `callback` in buildDefaultDispatcher() for readability. No behavior change. Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
Add unit tests for the default dispatcher's connect hook, the core of the #2709 socket-leak fix that had no direct coverage: success captures the socket into defaultSocket and calls back (null, socket); a connector error propagates without capturing; a (null, null) result synthesizes a "returned no socket" Error. Also drop the redundant "(issue #2709)" suffix from the two adjacent describe titles for consistency. Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
zimeg
left a comment
There was a problem hiding this comment.
@WilliamBergamin LGTM! Impressive patterns to this change I think. I'm leaving a comment about added options but nothing to stop this from next release 🚢 💨
|
|
||
| export const WS_READY_STATES = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED']; | ||
|
|
||
| const CLOSE_HANDSHAKE_TIMEOUT_MS = 30_000; |
There was a problem hiding this comment.
👁️🗨️ thought: This might be nice to surface as an option although I understand it's for default dispatcher so might be confusing in some cases...
There was a problem hiding this comment.
Yeah I thought about this also 🤔 But I think we can add this in later if we need to
| this.websocket = null; | ||
| clearTimeout(this.serverPingTimeout); | ||
| clearInterval(this.clientPingTimeout); | ||
| clearTimeout(this.closeHandshakeTimeout); |
There was a problem hiding this comment.
🧠 praise: Clever pattern to avoid confused logs!
Summary
Fixes #2709.
Porting
@slack/socket-modefrom thewslibrary to undici'sWebSocketin 3.0.0 silently dropped some force-close mechanismwsprovided. 😅 When a peer stops responding, the underlying TCP socket is never torn down and accumulates inESTABLISHEDstate, pushing apps toward Slack's ~10-connections-per-app cap until Socket Mode stops connecting.disconnect()waited forever for the close handshake.SlackWebSocket.close(1000)writes a CLOSE frame with no timer, so a dead peer that never replies leaves the socket stuck inCLOSINGandcleanup()never runs.CLOSE_HANDSHAKE_TIMEOUT_MS(30s) timeout now arms when the close frame is sent and forcescleanup()if the handshake stalls.cleanup()never destroyed the underlying TCP socket.WebSocketexposes no public.socket,terminate(), or abort signal. The raw socket lives on a private handler.SlackWebSocketnow creates its own dispatcher that captures the raw socket, and destroys it duringcleanup(). A user-supplied dispatcher owns its own socket and cannot be force-closed here; it falls back to the close-handshake timeout above. This limitation is documented in thedispatcheroption's JSDoc.Reconnect storm on
close.SocketModeClient's'close'close, with no guard against stale closes fired while a connection is still active, or against multiple closes stacking multiple reconnects.Known limitation: socket-level force-close only applies when the client creates its own
Agent(no user dispatcher). This is intentional and documented.Requirements